Анализ для стартапа, который продаёт продукты питания. Нужно разобраться, как ведут себя пользователи нашего мобильного приложения.
Цель исследования - Узнать, как пользователи доходят до покупки. Сколько пользователей доходит до покупки, а сколько — «застревает» на предыдущих шагах и на каких именно. Исследовать результаты A/A/B-эксперимента по изменению шрифта во всем приложении, выяснить какой шрифт лучше.
Ход исследования:
Шаг 1. Откройте файл с данными и изучите общую информацию
# импорт библиотек
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import scipy.stats as stats
import os
import math as mth
# импортируем функции для работы со временем
from datetime import datetime, timedelta
# импортируем библиотеку для работы со воронками
from plotly import graph_objects as go
# чтение файла с данными и сохранение в df
pth1 = '/datasets//logs_exp.csv'
pth2 = '/logs_exp.csv'
if os.path.exists(pth1):
df = pd.read_csv(pth1, sep='\t')
elif os.path.exists(pth2):
df = pd.read_csv(pth2, sep='\t')
else:
print('FilePathError')
# выведем первые пять строк датафрейма
df.head()
| EventName | DeviceIDHash | EventTimestamp | ExpId | |
|---|---|---|---|---|
| 0 | MainScreenAppear | 4575588528974610257 | 1564029816 | 246 |
| 1 | MainScreenAppear | 7416695313311560658 | 1564053102 | 246 |
| 2 | PaymentScreenSuccessful | 3518123091307005509 | 1564054127 | 248 |
| 3 | CartScreenAppear | 3518123091307005509 | 1564054127 | 248 |
| 4 | PaymentScreenSuccessful | 6217807653094995999 | 1564055322 | 248 |
# выведем информацию о датафрейме
df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 244126 entries, 0 to 244125 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 EventName 244126 non-null object 1 DeviceIDHash 244126 non-null int64 2 EventTimestamp 244126 non-null int64 3 ExpId 244126 non-null int64 dtypes: int64(3), object(1) memory usage: 7.5+ MB
В таблице 'df' 4 столбца, 244125 строк. В каждой строке информация об одном событии пользователя. Типы данных int64(3), object(1).
Выводы после обзора данных:
Предварительно можно утверждать, что данных достаточно для анализа.
План предобработки данных:
# меняем название столбцов на удобные для нас
df.rename(columns = {'EventName':'event_name', 'DeviceIDHash':'user_id', 'EventTimestamp':'event_time', 'ExpId':'group'}, inplace = True )
# меняем тип данных столбцов с датой
df['event_time'] = pd.to_datetime(df['event_time'], unit='s')
df['event_date'] = pd.to_datetime(df['event_time']).dt.normalize()
# проверяем дубликаты и пропуски в таблице df
print('Количество дубликатов в таблице df - ',df.duplicated().sum())
print('Количество пропусков в таблице df')
print(df.isna().sum())
Количество дубликатов в таблице df - 413 Количество пропусков в таблице df event_name 0 user_id 0 event_time 0 group 0 event_date 0 dtype: int64
В таблице нет пропусков, зато есть 413 дубликатов.
# удаляем дубликаты в таблице df
df = df.drop_duplicates().reset_index(drop=True)
# выводим количество событий и пользователей в таблице
print('Количество событий в таблице - ', df['event_name'].count())
print('Количество пользователей в таблице - ', df['user_id'].nunique())
Количество событий в таблице - 243713 Количество пользователей в таблице - 7551
# выводим среднее количество соыйтий на пользователя
print("Среднее количество событий на пользователя - {0:.2f}".format(df.groupby('user_id')['event_name'].count().median()))
Среднее количество событий на пользователя - 20.00
# выводим гистограмму распределения событий на пользователя
plt.figure(figsize=(15,5))
df.groupby('user_id').agg({'event_name':'count'})['event_name'].hist(bins = 30)
plt.title("Гистограмма распределения событий на пользователя")
plt.ylabel("Пользователи")
plt.xlabel("События")
plt.show()
На гистограмме видно, что у отдельных пользователей есть больше 2000 событий. Из за таких пользователе не видно основное распределение событий. Простроим дополнительный график с ограничением по количеству событий.
# выводим гистограмму распределения событий на пользователя с ограничением в количестве событий.
plt.figure(figsize=(15,5))
df.groupby('user_id').agg({'event_name':'count'})['event_name'].hist(bins = 30, range=(0, 88))
plt.title("Гистограмма распределения событий на пользователя")
plt.ylabel("Пользователи")
plt.xlabel("События")
plt.show()
У большей части пользователей число событий не превышает 40.
# выводим минимальное и максимальное значения в столбце date в таблице
print('Минимальная дата - ',df['event_date'].min())
print('Максимальная дата - ',df['event_date'].max())
Минимальная дата - 2019-07-25 00:00:00 Максимальная дата - 2019-08-07 00:00:00
# выводим гистограмму распределения событий по дате и времени
plt.figure(figsize=(15,5))
df['event_time'].hist(bins=14*24)
plt.title("Гистограмма распределения событий по дате и времени")
plt.xlabel("Дата")
plt.ylabel("Частота")
plt.show()
В логи новых дней по некоторым пользователям могут «доезжать» события из прошлого — это может «перекашивать данные». Мы распологаем полными данными только с 2019-08-01, данные до этой даты придется отбросить.
# отфильтровываем данные раньше 2019-08-01
df_filtered = df[df['event_date'] > '2019-07-31']
# выводим количество событий и долю потерь в таблице после фильтрации
print('Количество событий в таблице после фильтрации - ', df_filtered['event_name'].count())
print('Количество потерянных событий в таблице после фильтрации - ', df['event_name'].count() - df_filtered['event_name'].count())
print('Доля потерь событий после фильтрации {0:.2%}'.format(1 - (df_filtered['event_name'].count() / df['event_name'].count())))
print('Количество пользователей в таблице после фильтрации - ', df_filtered['user_id'].nunique())
print('Количество потерянных пользователей в таблице после фильтрации - ', df['user_id'].nunique() - df_filtered['user_id'].nunique())
print('Доля потерь пользователей в таблице после фильтрации {0:.2%}'.format(1 - (df_filtered['user_id'].nunique() / df['user_id'].nunique())))
Количество событий в таблице после фильтрации - 240887 Количество потерянных событий в таблице после фильтрации - 2826 Доля потерь событий после фильтрации 1.16% Количество пользователей в таблице после фильтрации - 7534 Количество потерянных пользователей в таблице после фильтрации - 17 Доля потерь пользователей в таблице после фильтрации 0.23%
# записываем отфильтрованные данные в основную таблицу
df = df_filtered
# выводим количество пользователей в группах
df.groupby('group').agg({'user_id' : 'nunique'}).sort_values(by='user_id', ascending=False)
| user_id | |
|---|---|
| group | |
| 248 | 2537 |
| 247 | 2513 |
| 246 | 2484 |
Количество пользователей в обоих группах примерно одинаковое, значит группы сбалансированны.
print('Количество пользователей, которые есть в нескольких группах одновременно:',
df.groupby('user_id').agg({'group' : 'nunique'}).query('group>1')['group'].count())
Количество пользователей, которые есть в нескольких группах одновременно: 0
Пользователи в группах не пересекаются, значит их распределили верно.
Вывод после предобработки данных
Привели название столбцов в таблице к удобному нам виду. Проверили таблицы на пропуски, дубликаты и аномалии. Пропусков, дубликатов и аномалий не обнаружено. Однако данные по событиям есть не за весь период наблюдений, из-за того что в логи новых дней по некоторым пользователям могут «доезжать» события из прошлого — это может «перекашивать данные». Мы распологаем полными данными только с 2019-08-01, данные до этой даты мы отбросили.
Данные готовы к анализу.
Изучим воронку продаж. Узнаем, как пользователи доходят до покупки, сколько пользователей доходит до покупки, а сколько — «застревает» на предыдущих шагах и на каких именно.
# выводим таблицу с событиями отсортированными по частоте
df.groupby('event_name').agg({'user_id':'count'}).sort_values(by='user_id', ascending=False)
| user_id | |
|---|---|
| event_name | |
| MainScreenAppear | 117328 |
| OffersScreenAppear | 46333 |
| CartScreenAppear | 42303 |
| PaymentScreenSuccessful | 33918 |
| Tutorial | 1005 |
Самое частое событие это MainScreenAppear, оно встречается 117 тысяч раз. Самое редкое Tutorial, всего 1 тысяча раз.
# выведм таблицу с количеством пользователей, которые совершали каждое из событий, а также их долю от общего числа пользователей
events_by_user = df.groupby('event_name').agg({'user_id':'nunique'}).sort_values(by='user_id', ascending=False)
events_by_user['% users'] = round((events_by_user['user_id']/df['user_id'].nunique())*100,2)
display(events_by_user)
| user_id | % users | |
|---|---|---|
| event_name | ||
| MainScreenAppear | 7419 | 98.47 |
| OffersScreenAppear | 4593 | 60.96 |
| CartScreenAppear | 3734 | 49.56 |
| PaymentScreenSuccessful | 3539 | 46.97 |
| Tutorial | 840 | 11.15 |
Почти все пользователи совершали событие MainScreenAppear, но есть и те кто обошел этот этап.
Событие Tutorial совершали всего 11% пользователей. Скорее всего событие Tutorial не является обязательным для совершения покупки.
В таком случае наша воронка представляет собой последовательность событий по убыванию доли пользователей:
MainScreenAppear → OffersScreenAppear → CartScreenAppear → PaymentScreenSuccessful
# посчитаем, какая доля пользователей проходит на следующий шаг воронки
sales_funnel = df.groupby('event_name').agg({'user_id':'nunique'}).sort_values(by='user_id', ascending=False).drop('Tutorial')
sales_funnel['% users'] = round((sales_funnel['user_id']/sales_funnel['user_id'].shift(1, axis=0))*100,2)
display(sales_funnel)
| user_id | % users | |
|---|---|---|
| event_name | ||
| MainScreenAppear | 7419 | NaN |
| OffersScreenAppear | 4593 | 61.91 |
| CartScreenAppear | 3734 | 81.30 |
| PaymentScreenSuccessful | 3539 | 94.78 |
Больше всего пользователей теряется при переходе на событие OffersScreenAppear, до него доходят только 61% пользователей. Меньше всего при переходе на событие PaymentScreenSuccessful около 4% пользователей.
# выведем диаграмму воронки
fig = go.Figure(go.Funnel(
y = sales_funnel.index,
x = sales_funnel['user_id'],
textposition = "inside"
))
fig.update_layout(height=600, width=800)
fig.show()
На диаграмме хорошо видно потери пользователей на каждом этапе воронки.
# выведем долю пользователей, которая доходит от первого события до оплаты
print("Доля пользователей, которая доходит от первого события до оплаты {0:.1%}"
.format(sales_funnel['user_id'].values [-1] / sales_funnel['user_id'].values [0]) )
Доля пользователей, которая доходит от первого события до оплаты 47.7%
Вывод после анализа воронки событий
Мы изучили воронку продаж пользователей мобильного приложения стартапа, который продаёт продукты питания.
Наша воронка представляет собой последовательность событий:
MainScreenAppear → OffersScreenAppear → CartScreenAppear → PaymentScreenSuccessful
Событие Tutorial не является обязательным, что бы стать покупателем, поэтому оно не входит в воронку продаж.
Выяснили, что до покупки (событие PaymentScreenSuccessful) доходит 47% пользователей, остальные пользователи «застревают» на предыдущих шагах.
Узнали, что большая часть пользователей теряется на MainScreenAppear - 39%, это значит что они поподают на главный экран, но не переходят к товарам (OffersScreenAppear), возможно им что-то мешает это сделать, например технические проблемы.
Еще часть на OffersScreenAppear - 20%, это значит, что они увидели наше предложение о покупке, но либо не добавили товар в корзину, либо добавили, но не перешли в нее (событие CartScreenAppear), возможной причиной здесь также могут быть технические проблемы, проблемы с качеством аудитории из рекламных каналов, а также с качеством самого предложения.
На переходе к событию PaymentScreenSuccessful теряется всего 4% пользователей, это значит что почти все кто дошел до корзины в итоге совершили покупку, это хороший показатель.
Исследуем результаты A/A/B-эксперимента о изменении шрифта во всем приложении. Пользователей разбили на 3 группы: 2 контрольные со старыми шрифтами (246 и 247) и одну экспериментальную (248) — с новыми. Выясним, какой шрифт лучше.
Для этого сравним доли пользователей, совершивших одно и тоже событие, в разных группах с помощью Z-критерия двух пропорций.
Критический уровень статистической значимости применим ɑ = 0,05.
При этом мы собираемся проверить 20 статистических гипотез:
Чтобы снизить вероятность ложнопозитивного результата при множественном тестировании гипотез применим поправку Бонферрони, т.е. разделим ɑ на количество гипотез.
ɑ = 0.05 / 20 = 0.0025
print('Количество пользователей в группе 246 - ', df[df['group'] == 246]['user_id'].nunique())
print('Количество пользователей в группе 247 - ', df[df['group'] == 247]['user_id'].nunique())
print('Количество пользователей в группе 248 - ', df[df['group'] == 248]['user_id'].nunique())
Количество пользователей в группе 246 - 2484 Количество пользователей в группе 247 - 2513 Количество пользователей в группе 248 - 2537
В каждой группе около 2500 пользователей, выборки сбалансированны.
# напишем функцию для проверки статистической значимости разницы между группами
def z_test (successes, sample):
alpha = .0025 # критический уровень статистической значимости с поправкой Бонферрони
# пропорция успехов в первой группе:
p1 = successes[0]/sample[0]
# пропорция успехов во второй группе:
p2 = successes[1]/sample[1]
# пропорция успехов в комбинированном датасете:
p_combined = (successes[0] + successes[1]) / (sample[0] + sample[1])
# разница пропорций в датасетах
difference = p1 - p2
# считаем статистику в ст.отклонениях стандартного нормального распределения
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/sample[0] + 1/sample[1]))
# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
distr = stats.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('p-значение: {0:.3f}'.format(p_value))
if p_value < alpha:
print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
else:
print(
'Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными'
)
Для начала удостоверимся в точности проведенного тестирования. У нас есть 2 контрольные группы для А/А-эксперимента, чтобы проверить корректность всех механизмов и расчётов. Проверим, находят ли статистические критерии разницу между выборками 246 и 247.
Сформулируем гипотезы.
Нулевая: различий в долях пользователей, совершивших одно и тоже событие между группами 246 и 247 нет.
Альтернативная: различия в долях пользователей, совершивших одно и тоже событие между группами 246 и 247 есть.
for e in df['event_name'].unique():
successes = [df.query('group == 246 and event_name == @e')['user_id'].nunique(),
df.query('group == 247 and event_name == @e')['user_id'].nunique()]
sample = [df.query('group == 246')['user_id'].nunique(), df.query('group == 247')['user_id'].nunique()]
print('Провека для события ',e,' в группах 246 и 247')
z_test(successes, sample)
print('')
Провека для события Tutorial в группах 246 и 247 p-значение: 0.938 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Провека для события MainScreenAppear в группах 246 и 247 p-значение: 0.757 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Провека для события OffersScreenAppear в группах 246 и 247 p-значение: 0.248 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Провека для события CartScreenAppear в группах 246 и 247 p-значение: 0.229 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Провека для события PaymentScreenSuccessful в группах 246 и 247 p-значение: 0.115 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Выводы после А/А эксперимента
Статистически достоверных различий в группах нет. Можно предположить что разбиение на группы работает корректно.
Сравнение группы 246 и 248
Проверим, находят ли статистические критерии разницу между выборками 246 (контрольной) и 248 (экспериментальной).
Сформулируем гипотезы.
Нулевая: различий в долях пользователей, совершивших одно и тоже событие между группами 246 и 248 нет.
Альтернативная: различия в долях пользователей, совершивших одно и тоже событие между группами 246 и 248 есть.
for e in df['event_name'].unique():
successes = [df.query('group == 246 and event_name == @e')['user_id'].nunique(),
df.query('group == 248 and event_name == @e')['user_id'].nunique()]
sample = [df.query('group == 246')['user_id'].nunique(), df.query('group == 248')['user_id'].nunique()]
print('Провека для события ',e,' в группах 246 и 248')
z_test(successes, sample)
print('')
Провека для события Tutorial в группах 246 и 248 p-значение: 0.826 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Провека для события MainScreenAppear в группах 246 и 248 p-значение: 0.295 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Провека для события OffersScreenAppear в группах 246 и 248 p-значение: 0.208 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Провека для события CartScreenAppear в группах 246 и 248 p-значение: 0.078 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Провека для события PaymentScreenSuccessful в группах 246 и 248 p-значение: 0.212 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Статистически значимой разницы между контрольной группой 246 и экспериментальной 248 нет.
Сравнение группы 247 и 248
Проверим, находят ли статистические критерии разницу между выборками 247 (контрольной) и 248 (экспериментальной).
Сформулируем гипотезы.
Нулевая: различий в долях пользователей, совершивших одно и тоже событие между группами 247 и 248 нет.
Альтернативная: различия в долях пользователей, совершивших одно и тоже событие между группами 247 и 248 есть.
for e in df['event_name'].unique():
successes = [df.query('group == 247 and event_name == @e')['user_id'].nunique(),
df.query('group == 248 and event_name == @e')['user_id'].nunique()]
sample = [df.query('group == 247')['user_id'].nunique(), df.query('group == 248')['user_id'].nunique()]
print('Провека для события ',e,' в группах 247 и 248')
z_test(successes, sample)
print('')
Провека для события Tutorial в группах 247 и 248 p-значение: 0.765 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Провека для события MainScreenAppear в группах 247 и 248 p-значение: 0.459 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Провека для события OffersScreenAppear в группах 247 и 248 p-значение: 0.920 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Провека для события CartScreenAppear в группах 247 и 248 p-значение: 0.579 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Провека для события PaymentScreenSuccessful в группах 247 и 248 p-значение: 0.737 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Статистически значимой разницы между контрольной группой 247 и экспериментальной 248 нет.
Сравнение объединённой контрольной группы и группы 248
Проверим, находят ли статистические критерии разницу между объедененными конрольными выборками 246, 247 и и эксперементальной группой 248.
Сформулируем гипотезы.
Нулевая: различий в долях пользователей, совершивших одно и тоже событие между объединённой контрольной группой и эксперементальной группой 248 нет.
Альтернативная: различия в долях пользователей, совершивших одно и тоже событие между объединённой контрольной группой и эксперементальной группой 248 есть.
for e in df['event_name'].unique():
successes = [df.query('group in (246, 247) and event_name == @e')['user_id'].nunique(),
df.query('group == 248 and event_name == @e')['user_id'].nunique()]
sample = [df.query('group in (246, 247)')['user_id'].nunique(), df.query('group == 248')['user_id'].nunique()]
print('Провека для события ',e,' в объединённой группе 246,247 и 248')
z_test(successes, sample)
print('')
Провека для события Tutorial в объединённой группе 246,247 и 248 p-значение: 0.765 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Провека для события MainScreenAppear в объединённой группе 246,247 и 248 p-значение: 0.294 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Провека для события OffersScreenAppear в объединённой группе 246,247 и 248 p-значение: 0.434 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Провека для события CartScreenAppear в объединённой группе 246,247 и 248 p-значение: 0.182 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными Провека для события PaymentScreenSuccessful в объединённой группе 246,247 и 248 p-значение: 0.600 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Статистически значимой разницы между объедененной контрольной группой 246,247 и эксперементальной 248 нет.
Выводы после A/B-теста
Мы исследовали результаты A/A/B-эксперимента о изменении шрифта во всем приложении.
Пользователи были разбиты на 3 группы, в каждой около 2 500 пользователей: 2 контрольные со старыми шрифтами (246 и 247) и одну экспериментальную (248) — с новыми.
Удостоверились в точности проведенного тестирования с помощью А/А эксперемента, сравнив группы 246 и 247 между собой по каждому событию.
Не обнаружили статистически значимой разницы между этими двумя группами для каждого из событий. Значит разбиение на группы работает корректно.
Сравнили контрольные группы с эксперементальной. С каждой из контрольных групп по отдельности и с объедененной контрольной группой по каждому событию.
Не обнаружили статистически значимой разницы между контрольными группами и эксперементальной для каждого из событий.
Из чего можно сделать вывод, что шрифт в приложении не влияет на поведение пользователей.